Skip to content

Add custom PWA web push notifications#1132

Open
claude-do wants to merge 16 commits intopingdotgg:mainfrom
mcorrig4:feature/web-push-pwa-pr
Open

Add custom PWA web push notifications#1132
claude-do wants to merge 16 commits intopingdotgg:mainfrom
mcorrig4:feature/web-push-pwa-pr

Conversation

@claude-do
Copy link

@claude-do claude-do commented Mar 16, 2026

Summary

  • add custom same-origin web push support for the web/PWA app
  • wire push subscription management into the settings UI and root bootstrap flow
  • add a push-capable service worker, manifest icon updates, and server-side notification sidecar plumbing
  • follow up with route error normalization and endpoint tests for the web push HTTP API

Verification

  • /home/claude/.bun/bin/bun fmt
  • /home/claude/.bun/bin/bun lint
  • env PATH="/home/claude/.bun/bin:$PATH" /home/claude/.bun/bin/bun typecheck
  • /home/claude/.bun/bin/bun run test src/notifications/policy.test.ts src/wsServer.test.ts
  • /home/claude/.bun/bin/bun run test src/appSettings.test.ts src/pwa.test.ts

Note

Add PWA web push notifications with service worker, server VAPID delivery, and subscription management

  • Adds a full web push notification pipeline: a service-worker.js with app shell caching and push event handling, client-side subscription lifecycle via usePushNotifications, and a Settings UI for enable/disable.
  • Adds server-side VAPID delivery via the web-push library in WebPushNotifications.ts, a web_push_subscriptions DB table (migration 014), and HTTP endpoints GET/PUT/DELETE /api/web-push/... in wsServer.ts.
  • Notification intents are derived from orchestration events (turn completed, approval requested, user-input requested) via a new policy.ts; notifications are suppressed when a visible window is already open.
  • Adds PWA manifests, icons, and runtime branding for a t3-dev host variant; the Vite dev server proxies /api/web-push to the backend origin from VITE_WS_URL.
  • Fixes stale pending user-input handling: ProviderService.respondToUserInput now fails fast with a validation error when the provider session is inactive, and the client clears the pending input entry when the failure detail indicates expiry.
  • Adds TTS playback for completed assistant messages via a native speech synthesis controller with markdown sanitization.
  • Risk: VAPID keys must all three be present or the server logs a warning and web push endpoints return 409; partial config silently disables push.
📊 Macroscope summarized 6e339ce. 57 files reviewed, 11 issues evaluated, 2 issues filtered, 3 comments posted

🗂️ Filtered Issues

apps/server/src/wsServer.ts — 0 comments posted, 2 evaluated, 1 filtered
  • line 742: The isWebPushRequestError(error) check at line 742 will never be true for two reasons: (1) the error is wrapped in a FiberFailure by Effect.runPromise, and (2) WebPushRequestError instances are already mapped to RouteRequestError at lines 520-525 before propagating. This means web push errors that should return 409 or 400 will instead return 500. [ Cross-file consolidated ]
apps/web/public/service-worker.js — 1 comment posted, 3 evaluated, 1 filtered
  • line 3: APP_SHELL_ASSETS includes both production and dev variants of assets (e.g., /manifest.webmanifest and /manifest-t3-dev.webmanifest, /favicon.ico and /favicon-dev.ico). cache.addAll() at line 52 requires all fetches to return successful (2xx) responses—if any asset returns 404, the entire service worker installation fails. In a production environment where dev assets don't exist (or vice versa), the service worker will fail to install. [ Failed validation ]

@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 48560849-ab0d-47c9-96f4-d7848d85feea

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can approve the review once all CodeRabbit's comments are resolved.

Enable the reviews.request_changes_workflow setting to automatically approve the review once all CodeRabbit's comments are resolved.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Mar 16, 2026
Comment on lines +129 to +135
Effect.tryPromise<SendResult, WebPushDeliveryError>({
try: () => webPush.sendNotification(input.subscription, input.payload),
catch: (error) =>
new WebPushDeliveryError({
message: error instanceof Error ? error.message : String(error),
}),
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/WebPushNotifications.ts:129

isPermanentDeliveryError receives a WebPushDeliveryError instance, which only has a message property. The original web-push error with statusCode is discarded at lines 131-134, so the check at line 178 always returns false. Subscriptions returning 404/410 are never deleted and are incorrectly treated as transient failures.

  const notifySubscription = (input: {
    readonly subscription: PushSubscription;
    readonly payload: string;
  }) =>
    Effect.tryPromise<SendResult, WebPushDeliveryError>({
      try: () => webPush.sendNotification(input.subscription, input.payload),
      catch: (error) =>
        new WebPushDeliveryError({
-          message: error instanceof Error ? error.message : String(error),
+          message: error instanceof Error ? error.message : String(error),
+          statusCode:
+            typeof error === "object" &&
+            error !== null &&
+            "statusCode" in error
+              ? (error as { statusCode: number }).statusCode
+              : undefined,
        }),
    });
Also found in 1 other location(s)

apps/server/src/wsServer.ts:742

The isWebPushRequestError(error) check at line 742 will never be true for two reasons: (1) the error is wrapped in a FiberFailure by Effect.runPromise, and (2) WebPushRequestError instances are already mapped to RouteRequestError at lines 520-525 before propagating. This means web push errors that should return 409 or 400 will instead return 500.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/notifications/Layers/WebPushNotifications.ts around lines 129-135:

`isPermanentDeliveryError` receives a `WebPushDeliveryError` instance, which only has a `message` property. The original web-push error with `statusCode` is discarded at lines 131-134, so the check at line 178 always returns `false`. Subscriptions returning 404/410 are never deleted and are incorrectly treated as transient failures.

Evidence trail:
apps/server/src/notifications/Layers/WebPushNotifications.ts lines 20-24 (WebPushDeliveryError class with only `message` property), lines 33-41 (`isPermanentDeliveryError` checking for `statusCode` property), lines 130-135 (error wrapping that discards `statusCode`), line ~177 (call to `isPermanentDeliveryError` with `WebPushDeliveryError` instance)

Also found in 1 other location(s):
- apps/server/src/wsServer.ts:742 -- The `isWebPushRequestError(error)` check at line 742 will never be true for two reasons: (1) the error is wrapped in a `FiberFailure` by `Effect.runPromise`, and (2) `WebPushRequestError` instances are already mapped to `RouteRequestError` at lines 520-525 before propagating. This means web push errors that should return 409 or 400 will instead return 500.

Comment on lines +83 to +91
try {
const response = await fetch(request);
const cache = await caches.open(APP_SHELL_CACHE);
await cache.put(APP_SHELL_URL, response.clone());
return response;
} catch {
const cachedShell = await caches.match(APP_SHELL_URL);
return cachedShell ?? Response.error();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High public/service-worker.js:83

The fetch handler caches HTTP error responses (4xx, 5xx) as the app shell. When fetch(request) returns a non-2xx response (e.g., navigating to a 404 page), that error response is cached under APP_SHELL_URL, corrupting the cache. On subsequent offline navigations, users are served the cached error page instead of the real shell. Add a check like response.ok before caching the response.

        try {
          const response = await fetch(request);
-         const cache = await caches.open(APP_SHELL_CACHE);
-         await cache.put(APP_SHELL_URL, response.clone());
-         return response;
+         if (response.ok) {
+           const cache = await caches.open(APP_SHELL_CACHE);
+           await cache.put(APP_SHELL_URL, response.clone());
+         }
+         return response;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/public/service-worker.js around lines 83-91:

The fetch handler caches HTTP error responses (4xx, 5xx) as the app shell. When `fetch(request)` returns a non-2xx response (e.g., navigating to a 404 page), that error response is cached under `APP_SHELL_URL`, corrupting the cache. On subsequent offline navigations, users are served the cached error page instead of the real shell. Add a check like `response.ok` before caching the response.

Evidence trail:
apps/web/public/service-worker.js lines 83-91 at REVIEWED_COMMIT: The fetch handler performs `const response = await fetch(request)` followed by `await cache.put(APP_SHELL_URL, response.clone())` without any `response.ok` check between them. HTTP error responses (4xx, 5xx) do not throw exceptions - they resolve normally and would be cached.

return createNativeSpeechController().isSupported();
}

export function getSnapshot(): TtsSnapshot {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium tts/tts.ts:66

getSnapshot returns a new object literal { ...snapshot, status: "unsupported" } on every call when TTS is unsupported. This violates the useSyncExternalStore contract that requires getSnapshot to return referentially equal values when the store data hasn't changed, causing unnecessary re-renders and potential tearing in concurrent React renders. Consider caching the "unsupported" snapshot or returning the same frozen object each time.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/features/tts/tts.ts around line 66:

`getSnapshot` returns a new object literal `{ ...snapshot, status: "unsupported" }` on every call when TTS is unsupported. This violates the `useSyncExternalStore` contract that requires `getSnapshot` to return referentially equal values when the store data hasn't changed, causing unnecessary re-renders and potential tearing in concurrent React renders. Consider caching the "unsupported" snapshot or returning the same frozen object each time.

Evidence trail:
apps/web/src/features/tts/tts.ts lines 67-73 (REVIEWED_COMMIT): `getSnapshot` returns `{ ...snapshot, status: "unsupported" }` - a new object literal - when `!isSupported() && snapshot.status === "idle"`.

apps/web/src/features/tts/useMessageTts.ts line 6 (REVIEWED_COMMIT): Confirms `getSnapshot` is used with `useSyncExternalStore`.

React documentation for `useSyncExternalStore`: https://react.dev/reference/react/useSyncExternalStore states getSnapshot must return a cached value when store hasn't changed.

@Noojuno
Copy link
Contributor

Noojuno commented Mar 16, 2026

@mcorrig4 Hey! This PR is very large, and contains multiple different fixes/changes in one. Could you please split this into small, targeted PR's? As it is now it is far too large and unfocused for someone to review.

I would also recommend creating issues for any bugs or features you may want to implement so there can be community discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants